Skip to content

feat(boards): share boards by role and keep project settings per member - #9797

Merged
joshistoast merged 37 commits into
mainfrom
feat/sharing-foundations
Oct 19, 2026
Merged

joshistoast merged 37 commits into
mainfrom
feat/sharing-foundations

Conversation

@joshistoast

@joshistoast joshistoast commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Lays the foundations for sharing in 7.0, so that later collaboration work (members, share dialog, project grants, locks) does not need another data migration.

Board roles. Every board has an owner and a role per caller: Viewer, Contributor, Editor or Owner, with administrators overriding.

  • One board_access service decides every board and item action. Routers, graph execution, workflow calls and upload events all use it.
  • A board that exists but the caller cannot open answers 403, as an image or video they cannot read does, so a revoked grant is never mistaken for a deleted board; only a missing board answers 404. An action their role does not allow also answers 403.
  • board_visibility and shared_boards become general_access (Restricted / Everyone can view / Everyone can contribute) plus a board_members table.
  • BoardDTO reports my_role, allowed_actions, admin_override and general_access.
  • Owners no longer permanently delete other people's media. Those items return to their own owner's Uncategorized. An administrator deleting their own board is an owner here; only an administrator deleting another user's board deletes everything on it.
  • Media DTOs carry project_id only for administrators and for the project's own owner.

Global project ids. Project ids are unique across accounts. Queue reads by origin are scoped to the caller.

Per-member project settings. A project's content (name, canvas, workflows) is saved apart from each member's workspace (layout, widgets, generation settings, prompt history, selection, staging):

  • project_workspaces table and PUT /projects/{id}/workspace
  • document schema 4, with a schema floor that refuses older clients
  • a project the server still holds whole is split by its first save: one PUT /projects/{id} carrying the content, the raised document floor and the member's workspace, stored in one transaction, so a save that half lands cannot lose the member's settings
  • workspace saves declare the newest canvas schema the client reads, as content saves do
  • .invk v3: project.json is the content, with an optional workspace.json behind Include my settings for this project
  • new canvas and workflow items get UUID-based ids

Gallery board UI:

  • Shared with me and, for administrators, Other users' boards, with rows like "Ana · Can view".
  • Board menus offer only what the caller's role allows, with a header saying whose board it is. Moving a board, from its menu or by dragging it onto another project or the Library, is offered only on the caller's own boards.
  • Access lets owners and administrators set general access.
  • Results, uploads and moves never target a board the caller can only view: results go to the project's Inbox, which is marked Auto.
  • A refused or revoked action refreshes the list with a notice.

What's New: a "Share boards" headline (multi-user only) and notes on Public boards and per-member settings.

Builds on #9713.

Related Issues / Discussions

Fixes the board half of #9673. Workflow sharing is still listed under known bugs.

QA Instructions

Ran on this branch:

Follow-up at 492c1be: pnpm check:release passes (lint, 10292 unit and 2925 browser tests, fixtures, architecture performance, project-file journeys, accessibility); pytest tests/app tests/fixtures -n 4: 8657 passed, 34 skipped, 5 xfailed; openapi.json and schema.ts regenerated; the shared API package's lint and tests pass. Verified in Chromium against the mock backend: revoking a member's access to their selected board moves the view to the project Inbox without rewriting the saved selection, which returns when access is granted again; the Change Board submenu and the workflow board field show the same folded listing as the panel. The three new migration tests now take the shared database fixture, so CI runs them on MySQL and MariaDB; only SQLite was available here.

First round:

  • pnpm -C invokeai/frontend/webv2 check:release:

    • lint
    • 10203 unit tests and 2918 browser tests
    • architecture performance
    • project-file journeys
    • accessibility
    • all pass. One browser test, ToastContrast (untouched here), can time out on a hover under a heavy full run; it passes 3/3 on its own.
  • uv tool run ruff@0.11.2 check / format --check on the 85 changed Python files: clean.

  • uv run --no-sync python -m pytest -n 4: 13406 passed, 102 failed, 193 skipped. Each failure was traced to this machine, not this branch:

    • 91 in tests/model_identification: the Git LFS fixtures are not fetched here.
    • 3 in test_app_info.py (busy database): they fail only while webv2/dist is built, because the UI's catch-all mount answers first. With the build moved aside, the file passes 36/36.
    • 2 (test_16_channel_vae_loader, minimax_h3/test_text_encoder_checkpoint) fail the same way on feat(boards): boards belong to projects, with a Library tier and whole-project transfer #9713's branch.
    • 1 (test_directory_install_rollback_race_preserves_both_source_artifacts) depends on the checkout filesystem's directory order. It passes 2/2 from a worktree of this branch, as on feat(boards): boards belong to projects, with a Library tier and whole-project transfer #9713's.
    • 5 (test_ministral3_encoder_config, test_mistral_encoder_checkpoint_loader, test_ideogram4_diffusers_loader, test_migration_loader) ran out of /tmp quota here and pass when rerun.
    • The run also reported 4 collection errors in test_model_cache_ram_budget.py, caused by running with -p no:warnings; the file passes without it.

    No failing test is in a file this branch changes.

  • MySQL and MariaDB run the same suite in CI; the one query-plan check (SQLite's EXPLAIN QUERY PLAN) is marked SQLite-only.

  • Each migration has its own tests; board roles are migrated from 6.13- and alpha.1-shaped data (test_migration_2026_10_10_{board_roles,global_project_ids,project_workspaces}.py).

To try sharing in the browser against the mock backend:

  1. node --input-type=module -e "import { startMockBackend } from './scripts/mock-backend.mjs'; await startMockBackend(9191, { profile: 'representative' });" from invokeai/frontend/webv2
  2. INVOKEAI_DEV_BACKEND=http://127.0.0.1:9191 pnpm exec vite dev --port 5273
  3. curl -X POST -H 'content-type: application/json' -d '{"intermediatesCaller":"user","sharedBoards":true}' http://127.0.0.1:9191/__faults
    • "multiuser-admin" instead of "user" gives the administrator view.
    • POST /__board-access revokes access behind the page's back.
  4. Sign in with any email and password, then open the Gallery:
    • Shared with me lists a board you can edit, one you can add to, and one you can only view.
    • Selecting the view-only board moves Auto to the Inbox.
    • Your own boards' menus have Access.

Verified in the browser this way:

  • each role's menu and the admin's
  • the Access submenu and its change notice
  • a revocation (notice, board removed)
  • the Inbox redirect and its tooltip
  • the disabled upload control
  • the What's New dialog in multi-user mode
  • export with and without settings, and import of v2 and v3 files

Review

Second review: a sign-out between the two requests of the lazy split could permanently wipe a member's workspace (the split is now one server transaction); losing access to a board was treated as the board being deleted (an unreadable board now answers 403, and the client keeps its references). Non-blocking findings fixed: an administrator deleting their own shared board hard-deleted other members' media; project_id leaked through image and video DTOs; a revoked or view-only destination was caught only at save time (destinations are decided from a fetched board listing on every submit, upload and canvas save, and failed runs and uploads refresh the listing); explicit board ids in workflow fields were never checked and the board field's options were cached for the session; a board created a moment ago could be overwritten by an older listing; whether results reached an archived board depended on cache timing; the workspace save ignored the canvas schema floor; the new migrations never ran against MySQL/MariaDB in CI; a share on a board with a NULL owner was dropped; re-keyed documents were re-serialized differently; identity was resolved differently at queue time; single-account board folding was applied in one place only; the mock backend had drifted; a workflow id of __proto__ lost its lastRun. The upgrade note about re-keyed project ids in open tabs is in the admin guide.

First review: material findings were fixed before merge. Among them:

  • Results could still go to a revoked or archived selected board, or, briefly, miss a just-created board; destination resolution now uses the newest relevant board listing.
  • Workflow board fields and field uploads now respect view-only boards.
  • An export with settings left out could still carry them for a project this build cannot read; it is now refused with a message.
  • A failed workspace save on import or duplicate is now reported.
  • Refusal ordering and document/canvas refusal reasons were corrected.
  • The release performance gate now passes; request counts are one lower on every route.

Remaining limits:

  • Media DTOs do not carry their owner, so the item menu cannot yet hide Delete or Remove on someone else's media. The server refuses them with its message.
  • In 7.0, exporting a closed project that is still stored whole includes the owner's settings. That is correct while projects have one member; 7.1 members will need the caller's own.

Compatibility / Rollout

  • Migrations: three linear migrations, after feat(boards): boards belong to projects, with a Library tier and whole-project transfer #9713's:

    • board roles (general_access, board_members; board_visibility/shared_boards dropped)
    • global project ids (re-keys duplicate ids with a logged warning; clears dangling boards.project_id)
    • project_workspaces plus minimum_document_schema_version

    Private boards become Restricted, shared boards Everyone can view, and public boards Everyone can contribute.

  • API:

    • board_visibility is kept as a deprecated alias derived from general_access until 8.0; sending both is a 422.
    • The upload socket event drops board_visibility, shared_user_ids and board_owner_id.
    • project_id on boards, images and videos is returned only to owners and administrators.
    • An existing board the caller cannot read answers 403 (Not authorized to access this board); 404 means the board does not exist.
    • PUT /projects/{id} takes an optional workspace for the save that splits a whole project, and answers ProjectWithWorkspaceDTO; a workspace on a save that leaves the document floor below 4 is 400 workspace_needs_split_document. PUT /projects/{id}/workspace takes max_canvas_schema_version.
    • openapi.json and schema.ts are regenerated.
  • Clients:

    • Earlier 7.0 alphas are refused projects saved in schema 4 (412 document_schema_unsupported) and refuse .invk v3 files.
    • This build still reads schema ≤3 projects and v2 files, and splits them on first save.
  • Docs: the multi-user user and admin guides ("Upgrading to 7.0"), the projects and gallery guides, the API guide, the database-layer guide and known bugs are updated.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Meaningful regression coverage added / updated where needed; obsolete tests/code removed
  • Persisted-state and API changes include required migrations / compatibility validation
  • Relevant performance/efficiency opportunities considered; material claims have evidence
  • Material review findings resolved and relevant checks rerun
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

Replace board visibility, is_public and shared_boards with board_members and general_access, read through the query layer.
Key projects by project_id across accounts, re-keying any shared id, and add per-member project_workspaces with a document schema floor.
Clear an account's board project memberships when the account is deleted.
Add a board access service that grants viewer, contributor, editor and owner actions from membership and general access; routers, graph execution, workflow calls and upload events all ask it.
Boards report general_access, my_role and allowed_actions, keep board_visibility as a deprecated alias, and answer 404 when unreadable and 403 when an action is not allowed.
Drop the unused general_access index, and document the roles and the API changes.
…o their owner

New draft projects mint project-<uuid> ids like every other client path; a create naming another account's id answers 409 and changes nothing.
Origin-scoped queue reads (item ids, current, next, status) return a non-admin only their own items, keeping their query plans.
Document global project ids and the queue scoping.
Projects read with the caller's workspace, which saves through its own revision-checked route and indexes its media separately.
A document schema floor refuses clients that predate the split; deleting a project or account drops every member's workspace references.
Document the content and workspace contract.
Layers, reference images, adjustments and workflow items take UUID-based ids from
createItemId instead of a timestamp with a short random suffix.
…'s workspace

Saves send the content (raising the document floor to 4) and then the caller's workspace,
each at its own revision, and loads join the two halves back into one document.
Drafts, recovery, conflicts, copies and duplicates track both halves, and the conflict banner names a settings-only conflict.
…settings

.invk v3 writes project.json as content beside an optional workspace.json, behind a remembered
"Include my settings for this project" option; v2 files still import, and newer schemas are refused from the manifest.
Imports and duplicates create the content, then save the creator's settings, warning when that save fails.
Groups the project halves module into the shared startup chunk, restoring the editor's request budget.
Boards shared with you list under Shared with me and admins' other-user boards under their own group; menus offer only what your role allows, with Access for owners.
Results, uploads and moves never target a board you can only view: they go to the project inbox, and refused or revoked access refreshes the list with a notice.
…ttings in What's New

Multi-user servers show a Share boards headline and the note that Public boards are now Everyone can contribute.
Every server notes that layout, generation settings and prompt history stay each member's own in a project.
@github-actions github-actions Bot added api python PRs that change python files services PRs that change app services frontend PRs that change frontend files python-tests PRs that change python tests docs PRs that change docs labels Oct 10, 2026
Board drag between projects is offered only on the caller's own boards their role lets them move.
A refused archive restores the auto-add board only if the user has not chosen another since.
The workspace reference query-plan check runs on SQLite only.
A board the caller can no longer open reads as gone when a project loads, as a deleted one does.
Board moves stay gated on the caller's role rather than board visibility: an owner can move a board whatever its general access.
Base automatically changed from feat/boards-library to main October 11, 2026 01:26
joshistoast and others added 5 commits October 10, 2026 22:21
Observe borrow-release wakeups directly and allow session GC to finish on busy runners. Give nested-loop failure cleanup a consistent integration-test deadline.
# Conflicts:
#	tests/app/services/session_processor/test_lent_device_scheduling.py

@lstein lstein left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this at 83d4a29 by trying to break each area: board authorization, migrations and SQL, the per-member workspace split, and the gallery client. The PR's own tests pass. I ran the migrations on 6.13- and alpha.1-shaped SQLite databases. I had no MySQL/MariaDB server, so I checked the server path by reading the code and compiling its DDL offline. I found two blockers. Both lose or corrupt members' data.

Blockers

1. The lazy split from schema 3 to 4 can permanently wipe a member's workspace

durableSyncedPersistence.ts:1510-1557 (pushHalves) splits a whole (schema-3) project in two separate requests. It first sends the content PUT with minimum_document_schema_version: 4, then sends PUT /projects/{id}/workspace. On the server, ProjectRecordService.update (project_records_default.py:298) replaces projects.data with the content alone. Between the two requests, the server no longer holds the member's layout, generation settings, prompt history, selection or staging.

recordRequiresDocumentUpgrade (:377-380) makes this split happen on the first save after opening, even when nothing changed. No user action is needed to trigger it.

Triggering sequence:

  1. Open a project saved before this PR.
  2. The content PUT commits.
  3. Before the workspace PUT lands, the user signs out. endLifetime aborts owner.signal, so assertOwner() throws before the workspace request is sent. accountOwnedWorkbenchDatabase.ts:51-59 then deletes the account's IndexedDB, local draft included. A tab closing, or a private window with no draft store plus a failed request, ends the same way.
  4. Every later load joins the content with createDefaultProjectWorkspace(). The member's settings are gone.

Before this PR every save was one whole-document transaction, so a save that only half landed could not destroy data already on the server. In the same window, media_references.replace(owner_kind="project") (:307) also drops the references to staged canvas candidates. Those candidates are intermediates, so cleanup can delete them while a surviving draft still names them.

Suggested fix: do the upgrade in one server transaction (content plus the first workspace row in one request), or keep the whole document until the workspace row exists.

2. Losing access to a board is now treated as the board being deleted

_require (routers/_access.py:22-37) now answers 404 for a board the caller can't read. On the base it answered 403, and the old get_board comment explained why: "clients take it as the board being gone, and clear what names it". The client side was not changed:

  • WorkbenchRuntime.tsx:46-75 checks every board a project names (selected board, auto-add board, queue items) through galleryBoardExists (gallery/data/backend.ts:1071-1087).
  • galleryBoardExists maps a 404 to "doesn't exist".
  • That runs reconcileDeletedGalleryBoard (workbenchState.ts:3051-3104).

Triggering sequence:

  1. A owns board X with general access Everyone can contribute.
  2. B selects X and generates into it.
  3. A sets X to Restricted.
  4. B reopens the project. In B's saved workspace:
    • selectedBoardId becomes 'none', so later results go to Uncategorized, not to the project Inbox the PR describes.
    • The auto-add board is reset.
    • Every locally known item on X, including B's own images that are still on X on the server, gets boardId: 'none'.

None of this is undone if A grants access again. A reversible permission change triggers an irreversible cleanup. Either the server needs a status that separates "gone" from "denied" (as #9394 did for media), or the client must stop treating this 404 as deletion.

Non-blocking

  • Admin owners still permanently delete other people's media. delete_board (boards.py:174) sets cascade_user_id = None for any admin. So an admin deleting their own shared board with include_images=true hard-deletes other members' uploads instead of returning them to their owners' Uncategorized. admin_override reports false in that case (board_access_common.py:109), so the UI can't warn. test_delete_board_with_include_images_admin_skips_user_filter asserts this behaviour.
  • project_id leaks through item DTOs. _describe hides project_id on a BoardDTO the caller doesn't own. ImageDTO.project_id / VideoDTO.project_id are returned unredacted to anyone who can view a project member board shared with them, through GET /images/i/{name}, the list endpoints and by-names. Combined with the 409 on project create for an existing id, this confirms the project exists.
  • A revoked or view-only destination is caught only at save time, and nothing refreshes afterwards.
    • There is no access-change event, and refetchOnWindowFocus is false. A cached add_media stays true after a downgrade.
    • With no listing cached at all (gallery widget closed for more than 5 minutes, or straight after startup), canAddToCachedGalleryBoard returns listings.length === 0 (queryCache.ts:480). That lets a view-only board through.
    • Either way the run fails in ImagesInterface.save after the whole generation. Boards are re-fetched only after a successful result (features/queue/runtime.ts:1054,1125,1244), so every later run fails the same way.
    • Failed uploads behave the same: useGalleryUploadAction.ts:160-168 returns without invalidateGallery.
  • Explicit board ids in workflow board fields are never checked. resolveBoardSentinel (buildGraph.ts:71-81) redirects only auto and empty values. BoardInput's option list is cached for the whole session (boardOptionsRequest), so it doesn't follow grants or revocations.
  • A board created a moment ago can be overwritten by an older listing. A boards fetch started before createBoard can land between the optimistic patch and scheduleGalleryInvalidation's board invalidation (queryCache.ts:690-717). Results then go to the Inbox, and no Auto badge is shown.
  • Whether results reach an archived board depends on cache timing. Whether results go to a hidden archived board depends on whether an include-archived listing is still cached (queryCache.ts:455-480).
  • The workspace save ignores the canvas schema floor. save_workspace checks only the document floor, and ProjectWorkspaceSaveRequest has no max_canvas_schema_version. Yet the workspace holds canvas-schema state (stagingArea, selectedLayerId).
  • The new migrations never run against MySQL/MariaDB in CI. All three test_migration_2026_10_10_* files call Database.open_sqlite directly, so they don't get the uses_database mark. The server branches therefore aren't exercised with data: the duplicate re-key, the DROP PRIMARY KEY, ADD PRIMARY KEY swap, and the shared_boards copy. test_portable_migrations runs that DDL on empty tables only.
  • A share on a board with a NULL owner is dropped. migration_2026_10_10_board_roles.py:68 filters on _shared_boards.c.user_id != _boards.c.user_id, which is NULL when boards.user_id is NULL. or_(_boards.c.user_id.is_(None), …) fixes it. Only hand-edited databases should hit this.
  • Re-keyed project documents are re-serialized differently. They go through plain json.dumps (migration_2026_10_10_global_project_ids.py:79,126). The app uses ensure_ascii=False with compact separators (project_records_default.py:42). A document near the size limit can come out over it.
  • Browser state for a re-keyed id has no migration path. An open tab's unsynced edits, or an IndexedDB draft, under the old id comes back as a "project deleted" conflict. One line in the release notes is probably enough.
  • Identity is resolved differently at queue time. workflow_call_batch._assert_user_can_access_board (:311-332) ignores single-user mode and reads is_admin from the demoted system user. BoardsInterface._viewer refuses a deactivated user but not a deleted one. ImagesInterface and VideosInterface refuse both.
  • Single-account board folding is applied in one place only. asSingleAccountBoards runs only in useGalleryBoards. The image context menu (ImageContextMenu.tsx:1073) and the workflow BoardInput show raw DTOs, so the same board appears as the user's own in the panel and under "Other users' boards" there.
  • The mock backend has drifted from the server. mock-backend.mjs:1094,1108,1762 still refuses to claim a board whose board_visibility !== 'private', and its seeded boards have no board_visibility.
  • A workflow id of __proto__ loses its lastRun. projectHalves.ts:83-88 writes into a plain object literal. Only a crafted file can do this, and only the run preview is lost.

What held up

  • Board authorization:
    • Every board, item, project and queue-origin route I traced goes through board_access.
    • Moves need AddMedia on the destination plus ownership of the item.
    • Removal is decided against the item's current board.
    • All delete paths use ItemAction.Delete.
    • item_action_for_changes fails closed.
    • The board_visibility alias can't be used to escalate, and sending both fields gets 422.
    • Revocation after enqueue wins at save and read time.
    • Socket audiences don't reach outsiders for a Restricted board.
  • Migrations:
    • The visibility mapping holds on 6.13- and alpha.1-shaped data.
    • Re-keying rewrites every server-side reference I could find: data, boards, images, queue project_id/origin, media references and client state.
    • SQLite rolls back cleanly on a mid-migration failure, and each crash point on a server converges on rerun.
  • Workspace split:
    • Workspaces are isolated per caller.
    • Old clients are refused on every write path, including those that send no version.
    • Concurrent saves of the two halves reconcile correctly.
    • The .invk v2/v3 import edge cases are handled.

…at a revoked board as refused

A whole project is split into content and the member's workspace by one request that stores both in one
transaction, so a save that half lands can no longer wipe the member's layout, settings and staging.
An existing board the caller cannot read answers 403 instead of 404, and the client keeps its references
to it; media DTOs carry a project id only for its owner and administrators; an administrator deleting
their own board no longer deletes other members' media.
Gallery destinations are decided from a fetched board listing on every submit, upload and canvas save,
workflow board fields follow revocations, and the new migrations run on every CI database.
…'s workspace

A save that raises the document floor across the split without a workspace answers 400 split_needs_workspace,
so no client can leave split content stored beside no workspace row. The mock backend mirrors both split refusals.
A split project created at floor 4 holds no workspace row until the creator's first workspace save; the guard only
keeps the split of a whole project from landing without the member's workspace.
@joshistoast
joshistoast requested a review from lstein October 17, 2026 17:33

@lstein lstein left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second round, at 32880d7 (fixes 492c1be, 21b03e1, 06af3ff). Thanks: both blockers from the last review are fixed, and so are most of the non-blockers. Two small backend issues are left before this can merge. One was missed by the fix; the other was introduced by it. I've also listed some follow-ups that don't need to block.

Previous blockers: fixed

  • Split atomicity. update() now writes the content, the board rename, the project media references, the member's workspace and the project_workspace references in one transaction. I forced a workspace conflict on a split and the revision, floor (3) and references were left exactly as before. Staged media moves from project to project_workspace in the same commit. The split_needs_workspace guard refuses every bypass I tried, and none of this client's paths can trigger it. Lost answers, reloads, sign-out mid-request and unload-journal replay all go through the combined request and reconcile correctly.
  • Revoked board treated as deleted. _require answers 403 for a board that exists but can't be read, and 404 only on BoardRecordNotFoundException. The probe is a raw column read, so a corrupt row gives a 500, never a "gone". galleryBoardExists returns true on 403, so no reconcile runs. Results go to the Inbox, the saved state keeps the board, and a regrant restores both the selection and the destination.

Also fixed:

  • An admin who owns a board no longer deletes other members' media.
  • The workspace save checks the canvas floor.
  • Explicit board ids in workflow fields are checked, and the field's board options follow the shared listing.
  • queue_viewer handles identity consistently.
  • The new migrations run on the MySQL and MariaDB jobs (860 passed at this head).
  • The NULL-owner share copy is fixed.
  • Re-keyed documents are serialized the way the app serializes them.
  • The mock backend matches the server.
  • The __proto__ lastRuns case is gone.

Changes requested

1. Video recall routes still return another user's project_id

_place_gallery_video (invokeai/app/api/routers/video_recall.py:578-590) calls assert_video_read_access, then videos.get_dto, and returns the DTO without redact_project_ids. This is the only router that returns a media DTO without redacting it. I checked every get_dto call in routers/.

Trigger:

  1. user1 has a video in one of their projects, on a board with Everyone can view.
  2. user2 calls GET /api/v1/videos/i/{name} and gets project_id: null.
  3. user2 calls POST /api/v1/recall/video/default/initial-video?video_name={name}. The response's video.project_id is user1's project id. /reference-video and /conditioning-video do the same.

This contradicts the API guide ("everyone else reads null"). Combined with the 409 on project create, it confirms the project exists. Fix: redact_project_ids([video], current_user) in _place_gallery_video, plus a test next to the existing redaction tests. The upload variants return the caller's own media and are fine.

2. The project-id migration can now fail on a client-state value, and the app won't start

migration_2026_10_10_global_project_ids.py:50 now serializes with ensure_ascii=False. In the client-state rewrite (:121-132), json.loads turns an escaped lone surrogate (e.g. "\ud83d") into a lone surrogate character. JSON.stringify writes exactly that for a string cut mid-emoji. _dumps then writes the character out raw. The UTF-8 encode fails at conn.execute, which is outside the try/except ValueError:

MigrationError: 'utf-8' codec can't encode character '\ud83d' ... surrogates not allowed

Trigger: two accounts hold the same project id (so _rekey runs), and the re-keyed account has a client_state value that contains the old id and such an escape. I reproduced it with the PR's own before fixture. With the previous json.dumps (ASCII escapes), the same input passes.

Project documents aren't affected, because _serialize_project_document already refuses lone surrogates on write. Only client_state stores the client's raw string. Duplicate ids are rare, but when this happens startup is blocked. Fix: encode inside the try (rewritten_value.encode("utf-8")) and skip the row on failure, or keep ensure_ascii=True for client state, which has no size limit to protect. A test with a surrogate escape would pin it.

Recommended follow-ups (non-blocking)

  1. A failed board-list refetch sends results to the Inbox without telling the user.
    • Where: canAddToCachedGalleryBoard (features/gallery/data/queryCache.ts:464-484) filters on query.state.status === 'success'.
    • Why it happens: when a refetch fails, TanStack Query keeps the data but sets status: 'error', so the only listing drops out and the function returns false. This contradicts the comment in ensureGalleryBoardDestinations ("the cached listings, if any, decide").
    • Trigger: with show archived on, the panel's listing is also the ensured one. One of its five requests (/boards or an Uncategorized count) returns 500.
    • Effect: every submit then goes to the Inbox. The panel still shows the chosen board and no redirect badge.
    • Suggestion: accept listings whose data is defined whatever their status. Add a test that seeds the include-archived key in an error state with data.
  2. Create paths still store content and the creator's workspace in two requests.
    • Where: createSaveDraftAsNew (durableSyncedPersistence.ts:146-167), the first save of a new project (the !base branch of pushHalves), and import/duplicate via saveTransferredWorkspace.
    • Effect: imports and duplicates can be redone from their source. A conflict copy ("save mine as a copy") and a new project's first save have no other copy. Signing out between the two requests wipes IndexedDB and loses the draft workspace for good.
    • This is the window this round closed for whole projects. Suggestion: let POST /projects take an optional workspace and write it in the same transaction.
  3. Some destinations still skip the ensure step or don't refresh after a refusal.
    • Video reference uploads read the upload board synchronously from the observed listing (app/VideoUiAdapter.tsx:100-105, VideoReferenceListField.tsx:562-582). Nothing refreshes that listing except invalidation, and touchGalleryImages runs only on success, so a revoked auto-add board 403s on every retry.
    • A refused canvas save to gallery (useCanvasGallerySave.ts:63-70) doesn't invalidate, and the next save within 60 s reuses the fresh listing.
    • queue/runtime.ts:440-450 refreshes after a failed run only for destination === 'gallery', so a canvas-bound workflow that names a board isn't covered.
    • useGalleryUploadAction.ts:99 still trusts an unlisted id "as given".
    • Suggestion: route these through ensureGalleryBoardDestinations and invalidate on any board refusal.
  4. Cost of the pre-submit listing.
    • Waiting at submit: after 60 s without an invalidation, every submit awaits a full listing (five requests) before dispatch (invocationSubmit.ts:205-208, 283-290). The preparation lease silently drops extra Invoke clicks in that window.
    • Extra request per completion: the always-mounted include-archived useQuery in VideoUiAdapter adds a second listing to every invalidateGallery.
    • Not measured: the browser baseline counts only initial asset requests, so neither cost is recorded.
    • Suggestion: measure it on the release gate. Consider resolving the destination optimistically from a cached listing and refreshing in the background, or queueing clicks that arrive during preparation instead of dropping them.
  5. Small items.
    • PasteMediaDialog.tsx:116-125 passes the unfolded listing, so a single-account install labels legacy boards by their stored names.
    • useGalleryActions.ts:33,72 tells "lost" from "stale" by matching the server's detail string. A structured code in the 403 body would be sturdier.
    • PUT /projects/{id}/workspace accepts a workspace beside a whole (floor-3) project, which update() would refuse as workspace_needs_split_document. Harmless with this client, but the two routes should agree.
    • Two small inconsistencies remain in the image/video interfaces and workflow_call_batch:
      • ImagesInterface/VideosInterface still inline the deleted/deactivated rule instead of using queue_viewer, although the docstring says everything resolves through it.
      • users.get raising in workflow_call_batch now escapes as a raw exception, not UnsupportedWorkflowNodeError. It still fails closed.

Checks

  • SQLite: tests/app/services/project_records, tests/app/routers/test_projects.py, the multiuser board/video/authorization suites, board_access, test_workflow_call_batch, the invocation-context board tests and the whole sqlite_migrator directory: 152 + 558 passed.
  • MySQL and MariaDB: CI's jobs passed at this head (860 passed, 2 skipped). I had no server to run them locally.
  • Frontend: findings come from reading the code, plus a probe against the installed TanStack Query 5.102.8 confirming that data is kept on a failed refetch.

Video recall routes blank foreign project ids like the videos API; the project-id migration writes client state with ASCII escapes so a lone surrogate no longer blocks startup.
A workspace saved beside a whole project is refused as on the content route, a board listing whose refetch failed still decides destinations, and the paste dialog folds boards like the gallery.
@joshistoast
joshistoast requested a review from lstein October 18, 2026 19:06

@lstein lstein left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third round, at a818f71 (fix e24b1ed). Thanks. Both remaining blockers are fixed, and I found nothing new that blocks. Approving.

Previous blockers: fixed

  • Video recall leaked project_id. _place_gallery_video now calls redact_project_ids before it returns or emits the DTO, so /initial-video, /reference-video and /conditioning-video now match the videos API. The new test fails when the redaction is reverted.
  • Migration crash on a lone surrogate. Client state is now rewritten with ASCII escapes (_dumps_client_state). The output is pure ASCII, so the encode at conn.execute can't fail. Project documents keep the compact form, which is safe because their writes already refuse lone surrogates. The re-key test now includes a surrogate escape and fails when the old serializer is put back.

Also fixed from the follow-ups

  • canAddToCachedGalleryBoard now counts a listing that holds data even when its refetch failed. This has a test.
  • PasteMediaDialog folds boards the same way the gallery does.
  • PUT /projects/{id}/workspace now refuses a whole (floor < 4) project with workspace_needs_split_document, as update() does. I checked that this client can't hit that refusal: whole creates send no workspace, and whole updates return before the workspace save.
  • ImagesInterface/VideosInterface now resolve the queue user through _viewer/queue_viewer.

Possible follow-up issues (non-blocking)

  1. Create paths still store content and the creator's workspace in two requests. This affects createSaveDraftAsNew (conflict copy), the first save of a new project (the !base branch of pushHalves), and import/duplicate via saveTransferredWorkspace. If the user signs out between the two requests, the conflict copy's or new project's draft workspace is lost for good. Imports and duplicates can be redone from their source. Suggestion: let POST /projects take an optional workspace and write it in the same transaction.
  2. Some board destinations skip the ensure step or don't refresh after a refusal.
    • Video reference uploads (VideoUiAdapter.tsx, VideoReferenceListField.tsx) read the board from the observed listing, so a revoked auto-add board answers 403 on every retry.
    • A refused canvas save to gallery (useCanvasGallerySave.ts) doesn't invalidate the listing.
    • queue/runtime.ts refreshes after a failed run only for destination === 'gallery'.
    • useGalleryUploadAction.ts trusts an unlisted id as given.
    • Suggestion: route these through ensureGalleryBoardDestinations and invalidate on any board refusal.
  3. Cost of the pre-submit listing. After 60 s without an invalidation, each submit waits for a full listing (five requests), and the preparation lease drops extra Invoke clicks during that wait. The always-mounted include-archived query in VideoUiAdapter adds a second listing on every invalidateGallery. Neither cost is measured. Suggestion: measure it on the release gate, and consider resolving the destination optimistically with a background refresh, or queueing the dropped clicks.
  4. Small items.
    • useGalleryActions.ts tells "lost" from "stale" by matching the server's detail string. A structured code in the 403 body would be sturdier.
    • users.get raising in workflow_call_batch escapes as a raw exception rather than UnsupportedWorkflowNodeError. It still fails closed.

Checks

  • SQLite: video recall, projects, boards/projects, project_records, the whole sqlite_migrator directory, and the invocation-context image/video tests: 478 passed, 6 skipped.
  • Mutation check: reverting either blocker fix fails its new test.
  • CI at this head: 20 passed, 1 skipped, including the MySQL and MariaDB jobs.
  • Frontend changes were checked by reading the code and by CI only.

@lstein
lstein enabled auto-merge (squash) October 18, 2026 22:03
lstein and others added 4 commits October 18, 2026 18:03
…d destinations after refusals

POST /projects stores the creator's workspace in the content's transaction; every client create path sends it, and imports and duplicates stage the inbox first so it is named.
Board destinations resolve from any cached listing with a background refetch, refusals refetch that listing at once, uploads refuse boards the listing rejects, and board 403s carry a structured code.
…ale growth

Every route loads the English catalog, which board sharing grows by about 4 KB of used strings; request counts and source owners are unchanged.
@lstein

lstein commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator

Follow-up on 895e104 and e18983f. My approval stands: I found no blockers. Thanks for taking on the follow-ups. Here's what I checked and what I'd suggest next.

Checked

  • POST /projects saves the workspace in the same transaction. I tried each of these, and none left anything behind:
    • a workspace beside an unsplit (floor < 4) document, a canvas-version mismatch, and an oversized workspace;
    • a lone surrogate, and a duplicate id (yours or another account's);
    • a write that fails partway, a retry after a lost answer, and sign-out in the middle of a create.
  • Board 403s now carry a code. Status codes are unchanged (404 only for a missing board), so nothing new is revealed about whether a board exists. No code still matches the old detail strings.
  • workflow_call_batch fails closed when the account lookup raises.
  • Submits no longer wait for a fresh board listing when one is cached, so extra Invoke clicks aren't dropped. The always-on board-listing queries in the Generation and Video adapters are gone.
  • Perf re-record: otherRawBytes = minified en.json + 961 B, which gives exactly 218,616. The full performance gate passes locally at e18983f. The re-record also raises scriptRawBytes by about 40 KB on the editor routes. That's within the allowance, but the commit message only mentions locale growth.
  • Tests: 352 passed in the focused backend suites (projects, project_records, workflow_call_batch, boards) and 1391 across tests/app/routers. frontend-webv2-tests passes in CI.

Possible follow-up issues (non-blocking)

  1. An upload to the user's own new project inbox can be refused as "unlisted".
    • Cause: createProjectSettled invalidates the board listings. The cached data stays, so ensureGalleryBoardDestinations returns without waiting (queryCache.ts:545-555), and the new inbox isn't in that data. useGalleryUploadAction.ts:118 then reports uploadsUnavailableForUnlistedBoard.
    • Trigger: create, import or duplicate a project, then drop or paste a file before the panel's refetch lands.
    • Suggestion: wait for the read when a listing isInvalidated, and refresh in the background only when it's merely old.
  2. Every failed run cancels and re-reads the board listings, with no batching.
    • Where: queue/runtime.ts:~444 → invalidateGalleryBoardDestinations calls invalidateQueries directly. That cancels any read in flight and fires for every failed gallery-bound or workflow run, whatever caused it.
    • Trigger: a 30-item batch failing on a missing model causes 30 cancel-and-restart rounds of every listing the panel observes.
    • Suggestion: batch it like scheduleGalleryInvalidation, or pass cancelRefetch: false.
  3. The cached listing can now be stale for a whole session. AddMedia is checked only when the output is saved (invocation_context.py), and no event invalidates listings when a role is revoked.
    • Trigger: a role is revoked after the listing was read. The next run generates in full and then fails when saving. Only that first run is lost, and it shows as a failed queue item, but that's expensive for video.
    • Either cap the listing's age for submits, or document the trade-off. The canAddToCachedGalleryBoard docstring ("a run never fails on its board after generating") and the gallery README should match whichever you choose.
  4. The gallery README describes the old refresh. features/gallery/README.md:34 says a failure invalidates the gallery, but only the board listings are invalidated. Failed runs no longer refresh the gallery at all.
  5. Two upload paths don't refresh after a refusal.
    • The crop dialog's "Save to Uploads" (ReferenceImageCropDialog.tsx:~505-520) only reports the error.
    • WorkflowFieldInput.tsx:~672 calls only invalidateGallery.
    • Repeated saves keep choosing the cached board until a background refresh lands.
  6. The 403 detail on board routes changed from a string to an object. It's documented in api-guide.mdx, and webv1 isn't affected on board routes. Third-party clients that read detail as a string will break, so it's worth a release note.
  7. A create now carries content and workspace in one request. PROJECT_WRITE_REQUEST_MAX_BYTES (34 MiB, projects.py:37) now has to hold both, while each half may be up to 32 MiB. This matches the split-save PUT. It's worth stating in the API guide, or falling back to the two-step create on a 413.
  8. Test gaps.
    • No Python test covers board_action_denied; only board_unreadable is asserted.
    • useGalleryActions.browser.test.tsx checks call counts on mocks (ensure…toHaveBeenCalledOnce).
    • The crop-dialog refusal path has no test.
    • The mock backend answers POST /projects with 200, while the server answers 201. This predates the commit.

@lstein
lstein disabled auto-merge October 18, 2026 23:07
lstein and others added 2 commits October 18, 2026 19:15
Re-records the browser byte baselines for the merged tree: main's hint and recall additions plus board sharing's locale growth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-records the editor bundle baseline for the merged tree, adding board sharing's eager sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@joshistoast
joshistoast merged commit 4584f45 into main Oct 19, 2026
21 checks passed
@joshistoast
joshistoast deleted the feat/sharing-foundations branch October 19, 2026 00:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api docs PRs that change docs frontend PRs that change frontend files python PRs that change python files python-tests PRs that change python tests services PRs that change app services

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants